Skip to content

Anchor dev code and data directories on the running source tree - #1093

Open
johnml1135 wants to merge 1 commit into
mainfrom
fix/dev-dir-anchoring
Open

Anchor dev code and data directories on the running source tree#1093
johnml1135 wants to merge 1 commit into
mainfrom
fix/dev-dir-anchoring

Conversation

@johnml1135

@johnml1135 johnml1135 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

A Debug build now reads the DistFiles of the source tree it was built in. Before this, it read whichever tree the shared registry value HKCU\SOFTWARE\SIL\FieldWorks\9\RootCodeDir happened to name — on the machine that prompted this, a build in the main repo was loading parts, layouts, and configuration from an unrelated worktree under .tmp/worktrees/.

The reviewer's question here is "what did this break for installed FieldWorks?", and the answer is nothing: the new probe only matches a directory that has both DistFiles and FieldWorks.sln beside it, which no install has. The question worth your time is whether making the source tree outrank the registry is the right default.

Where to look

  • CodeDirectory / DataDirectory now return before consulting the registry when the running assembly is inside a source tree — the deliberate behavior change; FW_USE_REGISTRY_DIRS=1 restores the old precedence.
  • FindDevDistFiles walks up instead of assuming <assembly>/../../DistFiles, so it also works from Output/Debug/x64 and from a project's own bin folder. Three layouts pinned by tests.
  • The installed case is pinned by its own test: DistFiles present, no solution file, result null.
  • TidyRootDir is extracted from GetDirectory so both paths normalize trailing separators identically — the ~100 Path.Combine callers see no difference.
  • ProjectsDirectory is untouched. ProjectsDir stays a shared user preference across worktrees.

Deliberately not here

  • Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs keeps its registry-only resolution; it runs inside Paratext, never from a source tree.
  • No log line or warning when an existing RootCodeDir override stops applying.
  • Build/mkall.targets still writes RootCodeDir/RootDataDir; a source-tree build now ignores them.

Verification

.\build.ps1 -CommentHygiene -BuildTests succeeded (0 warnings, 0 errors; comment-hygiene clean). .\test.ps1 -CommentHygiene -SkipNative -TestProject Src\Common\FwUtils\FwUtilsTests\FwUtilsTests.csproj — 407/407 passed, including 6 new cases. Not run: the full suite, native tests, installer validation. Not done: a manual two-worktree launch.


Reading this a year from now — start here

This started as a question, not a bug report: "my local Debug build still looks at DistFiles — could it look at Output instead?" The investigation said no to the literal request and yes to the problem behind it. Both halves are recorded below, because the rejected half is the one that will otherwise be re-proposed.

There were no working documents to delete; the reasoning never existed anywhere but here.

Decisions, and why

The registry loses to the source tree, rather than the probe merely being fixed. Fixing the anchoring alone would have changed nothing on a real dev machine: GetDevDistFilesPath() only ever fed defaultDir, and GetDirectory returns the registry value whenever it is non-empty. On a dev machine it is always non-empty — Build/mkall.targets (setKeysInHKCU) writes it, and so does the winapp skill's launch script. The value is a single machine-wide slot shared by every worktree, so it names whichever tree ran last. That is the actual defect; the fixed-depth probe is a second, independent one.

FieldWorks.sln as the tree marker. The probe needs something that exists in a source tree and never beside an install. The installer harvests DistFiles\**\* — the contents, into the install root — so an install has neither a DistFiles folder nor a solution file at that level. Requiring both makes the installed path unreachable by construction rather than by convention.

FW_USE_REGISTRY_DIRS as the escape hatch, read through EnvironmentVariables.IsTrue. Reuses the repo's existing opt-in convention rather than adding a new registry value, which would have reintroduced the shared-slot problem it exists to escape.

No memoization. Each CodeDirectory get now walks up doing Directory.Exists + File.Exists per level. The old path opened and read a registry key on every get, so this is not a regression, and a static cache would freeze FW_USE_REGISTRY_DIRS for any future test that sets it in-process. Revisit only with a measurement.

Paths not taken

Pointing the code directory at Output/<Configuration> — the literal request. Output holds build artifacts only; the code/data payload exists solely in DistFiles. Counted in the tree at the time: Language Explorer 10 entries in DistFiles vs absent from Output/Debug; Parts 4 vs absent; Helps 7 vs absent; Icu70 present vs absent; Templates 42 vs 1. FlexStylesPath, FlexFolder, TemplateDirectory, and EditorialChecksDirectory would all have broken.

An overlay that probes Output first, then falls back to DistFiles. This cannot be expressed through the current API: CodeDirectory returns one string that ~100 call sites Path.Combine onto. An overlay needs a ResolveCodeFile(relativePath) seam instead — a much larger change, for a duplication problem that does not currently exist (only Templates overlaps at all, with one entry).

Just fixing the registry and stopping there. That is what unblocked the reporter (Resolve-FieldWorksDevRegistry.ps1 -Force, run before this branch existed), and it is what every worktree switch will need again tomorrow. It treats the symptom.

Evidence

Precedence, before this changeGetDirectory(RegistryKey, string, string) in Src/Common/FwUtils/FwDirectoryFinder.cs: rootDir is read from the registry, and defaultDir is used only if (string.IsNullOrEmpty(rootDir)). GetDevDistFilesPath() fed defaultDir. Hence: registry set → probe irrelevant.

The shared slotBuild/mkall.targets target setKeysInHKCU writes RootCodeDir, RootDataDir, and ProjectsDir to HKCU\SOFTWARE\SIL\FieldWorks\$(FWMAJOR) from $(dir-fwdistfiles). Nothing in Src/ writes those two values at runtime (searching for SetValue("RootCodeDir" outside tests returns no hits), so the value persists from whichever tree last built or launched.

Existing tests keep passing for a non-trivial reasonFwDirectoryFinderTests sets the registry to UtilsAssemblyDir/../../DistFiles, and InitializeFwRegistryHelperAttribute does the same. Under the new precedence those values are ignored, but the walk-up returns the same path for a test run out of Output/Debug, so the assertions still hold.

New coverageFindDevDistFiles_InsideSourceTree_FindsTreeDistFiles (Output/Debug, Output/Debug/x64, Src/Common/FwUtils/bin/Debug/net8.0), FindDevDistFiles_OutsideSourceTree_ReturnsNull, and CodeAndDataDirectory_PreferSourceTreeOverRegistry (RootCodeDir, RootDataDir), which points the registry at a fabricated other worktree and asserts both directories still resolve to this tree.

Preflight review details

The preflight found no Critical issues and two Important open questions, neither of which was put to the author (the author pre-authorized commit, push, and PR in the same instruction that requested the fix, so no interview was held). They are open questions for the reviewer, not dismissed findings:

  1. Registry precedence is inverted for dev builds. Anyone who deliberately pointed RootCodeDir at a non-tree location silently loses that override. Mitigated by FW_USE_REGISTRY_DIRS; not mitigated by any notification that the override stopped applying.
  2. The tree marker is FieldWorks.sln. If the solution is renamed or removed, every dev build silently falls back to the registry/Program Files path with no diagnostic. A second marker or a build-time assertion would harden this.

Minor: no memoization (considered and rejected, see Decisions); the Paratext plugin's parallel finder now differs in policy and neither file mentions the other; the new precedence test restores a fixture-owned registry value and would throw if the fixture stopped setting it.

Build and test evidence is in the Verification section above. The first build attempt failed on an unrelated ILRepack file lock on Output\Debug\SIL.LCModel.Core.dll.config; the rerun was clean. gitlint --commits HEAD~1..HEAD is clean.


This change is Reviewable

FwDirectoryFinder found the dev DistFiles by assuming the running
assembly sat exactly two levels below the tree root, and then let
HKCU RootCodeDir/RootDataDir override whatever it found. So a build
run from any other output folder missed DistFiles entirely, and
every worktree read the DistFiles named by the shared registry
value, which belongs to whichever tree last ran the build or the
launch script.

FindDevDistFiles now walks up from the running assembly to the
directory holding both DistFiles and FieldWorks.sln, and that tree
wins over the registry. An installed FieldWorks has no solution file
beside it, so it keeps reading the registry as before. Set
FW_USE_REGISTRY_DIRS to opt a dev build back into the registry.

Tests cover the walk from Output/Debug, from an architecture
subfolder, and from a project bin folder; the installed case; and
the precedence of the source tree over a registry value naming
another worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

NUnit Tests

    1 files  ±0      1 suites  ±0   10m 51s ⏱️ -13s
5 837 tests +6  5 756 ✅ +6  81 💤 ±0  0 ❌ ±0 
5 846 runs  +6  5 765 ✅ +6  81 💤 ±0  0 ❌ ±0 

Results for commit 2939a25. ± Comparison against base commit caeeeb1.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.60870% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.31%. Comparing base (caeeeb1) to head (2939a25).

Files with missing lines Patch % Lines
Src/Common/FwUtils/FwDirectoryFinder.cs 82.60% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1093   +/-   ##
=======================================
  Coverage   38.31%   38.31%           
=======================================
  Files        1507     1507           
  Lines      350524   350538   +14     
  Branches    40288    40290    +2     
=======================================
+ Hits       134302   134310    +8     
- Misses     186990   186994    +4     
- Partials    29232    29234    +2     
Files with missing lines Coverage Δ
Src/Common/FwUtils/FwDirectoryFinder.cs 66.51% <82.60%> (-3.96%) ⬇️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jasonleenaylor jasonleenaylor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnosis is right and the installed-build safety argument holds up — I checked it
rather than taking it on trust (see item 6). The "Paths not taken" section is the most
useful part of the body: rejecting the Output/<Configuration> idea with actual file
counts, and rejecting the overlay because CodeDirectory returns one string that ~100
sites Path.Combine onto, are both the right calls argued the right way. Worktree
behaviour is correct too — the walk stops at the innermost match, so an assembly under
fw/.claude/worktrees/X/Output/Debug resolves X/DistFiles, which is the point.

The through-line of what follows: this change is correct when it works and invisible when
it does not
. Every failure mode below degrades to "silently reads the wrong tree" rather
than to an error, which is the same class of bug the PR was written to eliminate.

1. The walk-up is a no-op on any path containing a space.

FwDirectoryFinder.cs:330 derives the start directory with
FileUtils.StripFilePrefix(Assembly.GetExecutingAssembly().CodeBase). I loaded SIL.Core.dll
and called it:

StripFilePrefix('file:///C:/My%20Repos/fw/Output/Debug/FwUtils.dll')
=> C:/My%20Repos/fw/Output/Debug/FwUtils.dll

It strips the scheme but does not unescape. Every Directory.Exists in the new walk then
misses, FindDevDistFiles returns null, and resolution falls back to the registry — the
exact stale-worktree bug this PR exists to fix, with no diagnostic.

This is the PR's to fix rather than inherited debt, because the same class already gets it
right fifteen lines away: ExeOrDllDirectory (:417-418) uses Uri.UnescapeDataString, as
does InitializeFwRegistryHelperAttribute.cs:43-47. The probe was promoted to authoritative
without being made correct.

Please have GetDevDistFilesPath call ExeOrDllDirectory instead of re-deriving the path a
second way, and add a test case whose fixture path contains a space — nothing currently
exercises that.

(Worth noting for item 3: FwUtils.cs:219 uses Assembly.GetExecutingAssembly().Location,
which is already a plain filesystem path and has no escaping problem. CodeBase is
presumably why this class does what it does — shadow-copying under test runners — so
ExeOrDllDirectory is still the right fix here, but Location is worth knowing about.)

2. Please drop FW_USE_REGISTRY_DIRS.

To be clear about what I am not saying: it is genuinely distinct from
FW_ROOT_CODE_DIR / FW_ROOT_DATA_DIR, and I looked hard at whether it duplicated them. It
does not. Those pin a specific path; this defers to whatever the registry currently says, and
there is no value you can set the pair to that means "the registry". Having managed code
honour that pair would also be a CI change — Build/Agent/Setup-FwBuildEnv.ps1:104-108
already sets both on every agent, and managed FwDirectoryFinder ignores them today — which
does not belong in a dev-ergonomics PR. And honouring them would apply to installed builds
too, where a stale exported variable would silently win. So the mechanism you chose is the
narrower and safer one.

The problem is discoverability. Nobody who needs it will know it exists. It is documented
only in the source, it is not named in the skill docs this PR updates, and a developer
hitting the problem it solves has no path to finding it. Unused configuration still has to be
maintained, tested and reasoned about, and it is one more environment variable in a space
that already has two honoured by a different layer with different semantics. If the need
turns up in practice, add it then, with documentation.

3. Decide, explicitly, whether FindDevDistFiles is the seam.

The PR calls the fixed-depth probe "a second, independent defect". FwUtils contains three
implementations of it and this fixes one:

  • FwDirectoryFinder.cs:328 — fixed here.
  • FwUtils.cs:215-241 TryGetDevIcuDataDir() — still Path.Combine(assemblyDir, "..", "..", "DistFiles").
  • StringTable.cs:71-113 — a third idiom, stripping at the last output segment.

Plus FwDirectoryFinder.SourceDirectory (:429-450), eleven lines below the new method,
still does two hard-coded GetDirectoryName hops and throws ApplicationException when they
miss.

The concrete result: after this PR a build run from Output/Debug/x64 — a layout
FindDevDistFiles_InsideSourceTree_FindsTreeDistFiles explicitly claims support for —
resolves code and data correctly but still fails to find dev ICU data and still throws from
SourceDirectory. One file, three answers.

I am not asking you to fix all of them. I am asking for a decision per site, stated: route it
through FindDevDistFiles, or keep it separate and say why. You made the method public
for reuse, so either it is the seam or it is not.

SourceDirectory is the sharpest case, being in this file and contradicting the new test's
own claim. ICU deserves extra thought rather than a default answer — it starts from
Location rather than CodeBase, it is a best-effort catch-all returning null, and it
runs on the CustomIcu.InitIcuDataDir() bootstrap path, so taking a new dependency on
FwDirectoryFinder there is an initialisation-order question, not just a refactor. It may
well be right to leave it alone; I would rather that be a conclusion than an omission.

4. ProjectsDirectory is not untouched.

The body says it is. The code is; the behaviour is not. FwDirectoryFinder.cs:502 is
GetDirectory(ksProjectsDir, Path.Combine(DataDirectory, ksProjects)) — its default derives
from DataDirectory, which this PR changed. On any machine where the HKCU/HKLM ProjectsDir
value is absent — a fresh dev box, a CI agent, a container — the projects directory moves
from %ProgramData%\SIL\FieldWorks\Projects to <tree>\DistFiles\Projects.

Where the registry value is set nothing changes, which is most established dev machines, so
this is narrow. But ProjectsDir resolution touches installer expectations and test-fixture
scratch directories, and a reviewer reading "untouched" will not check. Please correct the
claim and state plainly what happens where the registry value is absent.

5. Two problems in CodeAndDataDirectory_PreferSourceTreeOverRegistry.

RegistryKey.SetValue(name, null) throws ArgumentNullException. If the fixture setup at
FwDirectoryFinderTests.cs:38-39 ever stops seeding those values, this test fails with an
ArgumentNullException from the finally rather than the assertion that actually broke. Your
own preflight caught this; please guard it.

More importantly, expectedDir is computed as UtilsAssemblyDir/../../DistFiles — re-encoding
the exact fixed-depth assumption this PR removes. It passes today and misleads tomorrow, and
it sits in the file that is meant to be the evidence for the change. Derive it the way the
production code now derives it.

6. Installed-build safety: verified, with one question.

I checked this independently and you are right. Build/Installer.targets:133,149 harvests
$(fwrt)\DistFiles\**\* and copies with %(RecursiveDir), flattening the contents into the
staged app folder, so no DistFiles directory name survives; the only root-level repo file
installed is License.htm (:134), so no FieldWorks.sln either; and
FLExInstaller/Overrides.wxi:11-12 points RootCodeDir at APPFOLDER. The marker conjunction
cannot occur in an install tree.

The question: does anything in install validation or patch staging run the packaged binaries
from a path inside the checkout?
If so, the probe would prefer the tree's DistFiles over
the staged payload and could mask a packaging defect — a validator passing because it read the
source tree is a bad failure to have. Related: dropping FW_USE_REGISTRY_DIRS (item 2) removes
the only lever such a flow could have pulled, so it is worth answering this before dropping it.

7. Make the marker's disappearance loud.

ksSolutionFilename = "FieldWorks.sln" (:334) is the only thing distinguishing a source tree
from an install. Rename or remove the solution — plausible in an SDK-style consolidation — and
every dev build silently reverts to the registry with no diagnostic.

Please add a test that fails loudly if the marker stops existing at the repo root. That turns
"someone renamed the solution" from a confusing dev-environment regression into a red build,
which is the cheapest possible guard on the whole mechanism.

8. TidyRootDir's doc comment.

FwDirectoryFinder.cs:320-323 reads "Strips the trailing separator that hundreds of callers
would otherwise pass on to Path.Combine". It names its consumers, who change silently, and it
describes what the method does to them rather than its own contract. The pre-existing // said
the same thing, but this PR promoted it to a doc comment, so it is fair game.

Something closer to: returns the directory without a trailing separator, except at a drive root,
where Path.Combine requires one.

9. Four smaller comment items.

  • FindDevDistFiles <remarks> (:344-348): "Walking up to the tree root, rather than
    assuming a fixed depth" narrates what the code no longer does; the rest enumerates three
    folder layouts, restating the [TestCase] list.
  • <param name="startDirectory"> (:349) restates the name and type only — omit it.
  • ksUseRegistryDirsVariable (:337): "let the registry name the directories again" implies a
    prior state the reader cannot see. Moot if item 2 lands.
  • FindDevDistFiles_InsideSourceTree_FindsTreeDistFiles's doc restates the method name and
    narrates removed behaviour.

Credit where it is due: the <summary> null contract ("or null if it lies outside a source
tree") is exactly the kind of tag worth keeping, and CreateFakeSourceTree's doc explains
fixture shape rather than mechanics. The comments in this file are better than most.

10. Two documentation items.

.claude/skills/fieldworks-winapp/SKILL.md:123 — "Current builds anchor on their own source
tree, so this now matters mainly for older builds" — is temporal framing that will read as
wrong within a release or two. Say what is true rather than what recently changed.

And an accuracy note on the body's reasoning, which is otherwise a good record: it attributes
the shared registry slot to Build/mkall.targets target setKeysInHKCU, but mkall.targets:277
carries a REVIEW (Hasso) 2026.03 comment saying those targets "appear unused by the
recently-modernized build process". The likelier live writers are the MSI
(FLExInstaller/Overrides.wxi:11-12) and Resolve-FieldWorksDevRegistry.ps1:54-55. The defect
is real either way — it would just be a shame for the record to name the wrong culprit.


This review was assisted by Claude Fable 5.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants